<!doctype html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Voice QSO Logger</title>
    <style>
        :root {
          --bg:#f5f7fb; --card:#fff; --border:#e6e9f2; --text:#1f2937; --muted:#6b7280;
          --primary:#2563eb; --success:#10b981; --warn:#f59e0b; --danger:#ef4444;
        }
        * { box-sizing: border-box; }
        body { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial; margin: 0; background: var(--bg); color: var(--text); }
        .container { max-width: 1080px; margin: 24px auto; padding: 0 16px; }
        h2 { margin: 8px 0 16px; }
        .toolbar { display:flex; flex-wrap:wrap; align-items:center; gap:8px; background: var(--card); border:1px solid var(--border); border-radius:12px; padding:12px; }
        .toolbar input, .toolbar select { height:36px; padding: 0 10px; border:1px solid var(--border); border-radius:8px; background:#fff; }
        .toolbar button { height:36px; padding: 0 14px; border:1px solid var(--border); background:#fff; border-radius:8px; cursor:pointer; }
        .toolbar button.primary { background: var(--primary); color:#fff; border-color: var(--primary); }
        .statusbar { display:flex; gap:16px; align-items:center; margin-top:12px; }
        .badge { display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:999px; font-size:12px; border:1px solid var(--border); background:#fff; color:var(--muted); }
        .badge .dot { width:8px; height:8px; border-radius:50%; background: var(--muted); }
        .badge.mode-local .dot { background: var(--success); }
        .badge.mode-api .dot { background: var(--primary); }
        .grid { display:grid; grid-template-columns: repeat(6, 1fr); gap:12px; margin-top:16px; }
        .card { background: var(--card); border:1px solid var(--border); border-radius:12px; padding:12px; }
        .kv { display:flex; flex-direction:column; gap:4px; }
        .kv .label { font-size:12px; color: var(--muted); letter-spacing:.5px; }
        .kv .value { font-size:18px; min-height:24px; font-weight:600; }
        .section { margin-top:16px; }
        .logbox { white-space: pre-wrap; background: var(--card); padding: 12px; border: 1px solid var(--border); height: 220px; overflow: auto; border-radius:12px; }
        table { border-collapse: collapse; width: 100%; background: var(--card); border:1px solid var(--border); border-radius:12px; overflow:hidden; }
        th, td { border-bottom: 1px solid var(--border); padding: 10px; text-align:left; }
        th { background:#fafbff; color:#374151; font-weight:600; }
        tr:hover td { background:#fafafa; }
        textarea { width: 100%; height: 120px; border:1px solid var(--border); border-radius:12px; padding:10px; }
        @media (max-width: 900px){ .grid { grid-template-columns: repeat(2, 1fr);} }
        .by { font-size:12px; color: var(--muted); margin-left:8px; font-weight:500; }
    </style>
</head>
<body>
<div class="container">
  <h2>Voice QSO Logger <span class="by">by BD1BWV</span></h2>
  <div class="toolbar">
    <label>服务器</label>
    <input id="wsUrl" style="width:360px" value="ws://localhost:8080/ws/asr"/>
    <label>语言</label>
    <select id="lang"><option>ZH</option><option>EN</option></select>
    <label>采样率</label>
    <select id="rate"><option>16000</option></select>
    <label>设备</label>
    <select id="deviceSelect"><option value="">默认设备</option></select>
    <button onclick="refreshDevices()">刷新设备</button>
    <button class="primary" onclick="connectWs()">连接</button>
    <button onclick="disconnectWs()">断开</button>
    <button onclick="startStream()">开始</button>
    <button onclick="stopStream()">停止</button>
    <button onclick="toggleMic()">麦克风开/关</button>
    <button onclick="manualNewQso()">手动新通联</button>
  </div>
  <div class="statusbar">
    <span id="wsState" class="badge"><span class="dot"></span>未连接</span>
    <span id="modeBadge" class="badge">识别模式：未知</span>
    <span id="lastUpdated" class="badge">最后更新：—</span>
    <span id="asrCost" class="badge">ASR：—</span>
    <span id="llmCost" class="badge">LLM：—</span>
  </div>

  <div class="grid">
    <div class="card kv"><div class="label">呼号</div><div class="value" id="v_callsign">—</div></div>
    <div class="card kv"><div class="label">信号报告</div><div class="value" id="v_rst">—</div></div>
    <div class="card kv"><div class="label">设备</div><div class="value" id="v_rig">—</div></div>
    <div class="card kv"><div class="label">天线</div><div class="value" id="v_antenna">—</div></div>
    <div class="card kv"><div class="label">功率</div><div class="value" id="v_power">—</div></div>
    <div class="card kv"><div class="label">高度</div><div class="value" id="v_height">—</div></div>
  </div>

  <div class="section">
    <h3>实时结果</h3>
    <div id="log" class="logbox"></div>
  </div>

  

  <div class="section">
    <h3>通联日志列表</h3>
    <button onclick="loadQso()">刷新</button>
    <table id="qsoTable"><thead><tr><th>时间</th><th>呼号</th><th>RST</th><th>设备</th><th>天线</th><th>功率</th><th>高度</th><th>位置</th></tr></thead><tbody></tbody></table>
  </div>

  <div class="section">
    <details>
      <summary style="cursor:pointer; color: var(--muted);">文本解析预览</summary>
      <div style="margin-top:10px;"><textarea id="previewText" placeholder="在此粘贴一段识别文本，点击解析"></textarea></div>
      <div style="display:flex; gap:8px; align-items:center; margin-top:8px;">
        <label>我的呼号：</label>
        <input id="myCallsign" placeholder="可选，如 BD1BWV" style="height:36px;padding:0 10px;border:1px solid var(--border);border-radius:8px;"/>
        <button onclick="doParse()">解析</button>
      </div>
      <pre id="parseOut"></pre>
    </details>
  </div>
</div>

<script>
let ws;
let mediaStream;
let audioContext;
let processor;
let debounceTimer=null;
let connected=false;

function logLine(msg){
  const box=document.getElementById('log');
  box.textContent += msg + "\n";
  box.scrollTop = box.scrollHeight;
}

function connectWs(){
  const url=document.getElementById('wsUrl').value;
  ws=new WebSocket(url);
  ws.binaryType='arraybuffer';
  ws.onopen=()=>{connected=true; setConnBadge(true);};
  ws.onclose=()=>{connected=false; setConnBadge(false);};
  ws.onmessage=(ev)=>{
    try{
      const data=JSON.parse(ev.data);
      if(data.event==='partial'){
        logLine('[partial] '+data.text);
      } else if(data.event==='final'){
        logLine('[final] '+data.text);
      } else if(data.event==='qso_snapshot'){
        updateSnapshot(data);
        debounceLoadQso();
      } else if(data.event==='asr_progress'){
        setAsrCost('进行中…');
      }
    }catch(e){ logLine(ev.data); }
  };
}

function disconnectWs(){ if(ws){ ws.close(); ws=null; } }

function startStream(){ if(!ws||ws.readyState!==1) return alert('未连接');
  ws.send(JSON.stringify({type:'start', sampleRate: Number(document.getElementById('rate').value), lang: document.getElementById('lang').value}));
}
function stopStream(){ if(ws&&ws.readyState===1) ws.send(JSON.stringify({type:'stop'})); }

async function toggleMic(){
  if(mediaStream){ stopMic(); return; }
  
  const deviceId = document.getElementById('deviceSelect').value;
  const audioConstraints = {
    channelCount: 1, 
    sampleRate: 16000,
    echoCancellation: false,
    noiseSuppression: true,
    autoGainControl: false
  };
  
  if (deviceId) {
    audioConstraints.deviceId = { exact: deviceId };
  }
  
  try {
    mediaStream = await navigator.mediaDevices.getUserMedia({audio: audioConstraints});
    audioContext = new (window.AudioContext||window.webkitAudioContext)({sampleRate:16000});
    const source = audioContext.createMediaStreamSource(mediaStream);
    processor = audioContext.createScriptProcessor(4096, 1, 1);
    processor.onaudioprocess = e => {
      if(!ws||ws.readyState!==1) return;
      const input = e.inputBuffer.getChannelData(0);
      const pcm16 = floatTo16BitPCM(input);
      ws.send(pcm16);
    };
    source.connect(processor);
    processor.connect(audioContext.destination);
    logLine('已选择设备并开始采集音频');
  } catch (error) {
    logLine('获取音频设备失败: ' + error.message);
  }
}

function stopMic(){
  if(processor){ processor.disconnect(); processor=null; }
  if(audioContext){ audioContext.close(); audioContext=null; }
  if (mediaStream) { mediaStream.getTracks().forEach(t=>t.stop()); mediaStream=null; }
}

function floatTo16BitPCM(float32Array) {
  const buffer = new ArrayBuffer(float32Array.length * 2);
  const view = new DataView(buffer);
  let offset = 0;
  for (let i = 0; i < float32Array.length; i++, offset += 2) {
    let s = Math.max(-1, Math.min(1, float32Array[i]));
    view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
  }
  return buffer;
}

async function doParse(){
  const text = document.getElementById('previewText').value;
  const my = document.getElementById('myCallsign').value;
  const res = await fetch('/api/parse/qso', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({text, myCallsign: my})});
  const data = await res.json();
  document.getElementById('parseOut').textContent = JSON.stringify(data, null, 2);
}

async function loadQso(){
  const res = await fetch('/api/qso/list?page=1&size=20');
  const page = await res.json();
  const tbody = document.querySelector('#qsoTable tbody');
  tbody.innerHTML='';
  for(const r of (page.records||[])){
    const tr=document.createElement('tr');
    tr.innerHTML = `<td>${(r.createdAt||'').toString().replace('T',' ')}</td><td>${r.callsign||''}</td><td>${r.signalReport||''}</td><td>${r.rig||''}</td><td>${r.antenna||''}</td><td>${r.power||''}</td><td>${r.height||''}</td><td>${r.location||''}</td>`;
    tbody.appendChild(tr);
  }
}

async function refreshDevices() {
  try {
    // 请求权限以获取设备列表
    await navigator.mediaDevices.getUserMedia({audio: true});
    const devices = await navigator.mediaDevices.enumerateDevices();
    const audioInputs = devices.filter(device => device.kind === 'audioinput');
    
    const select = document.getElementById('deviceSelect');
    select.innerHTML = '<option value="">默认设备</option>';
    
    audioInputs.forEach(device => {
      const option = document.createElement('option');
      option.value = device.deviceId;
      option.textContent = device.label || `麦克风 ${device.deviceId.substring(0, 8)}`;
      select.appendChild(option);
    });
    
    logLine(`发现 ${audioInputs.length} 个音频输入设备`);
  } catch (error) {
    logLine('获取设备列表失败: ' + error.message);
  }
}

function setConnBadge(ok){
  const el=document.getElementById('wsState');
  el.className = 'badge';
  el.innerHTML = `<span class="dot" style="background:${ok?'var(--success)':'var(--danger)'}"></span>${ok?'已连接':'未连接'}`;
}

function setModeBadge(mode){
  const el=document.getElementById('modeBadge');
  const cls = mode==='api' ? 'badge mode-api' : mode==='local' ? 'badge mode-local' : 'badge';
  el.className = cls;
  el.textContent = '识别模式：' + (mode||'未知');
}

function updateSnapshot(d){
  setModeBadge(d.mode);
  document.getElementById('v_callsign').textContent = d.callsign||'—';
  document.getElementById('v_rst').textContent = d.signalReport||'—';
  document.getElementById('v_rig').textContent = d.rig||'—';
  document.getElementById('v_antenna').textContent = d.antenna||'—';
  document.getElementById('v_power').textContent = d.power||'—';
  document.getElementById('v_height').textContent = d.height||'—';
  document.getElementById('lastUpdated').textContent = '最后更新：' + new Date().toLocaleTimeString();
  if (d.asrCostMs !== undefined) setAsrCost(d.asrCostMs + ' ms');
  if (d.llmCostMs !== undefined) setLlmCost(d.llmCostMs + ' ms');
}

function debounceLoadQso(){
  if(debounceTimer) clearTimeout(debounceTimer);
  debounceTimer = setTimeout(loadQso, 1200);
}

function setAsrCost(text){ document.getElementById('asrCost').textContent = 'ASR：' + text; }
function setLlmCost(text){ document.getElementById('llmCost').textContent = 'LLM：' + text; }

function manualNewQso(){
  if(!ws||ws.readyState!==1){ alert('未连接'); return; }
  ws.send(JSON.stringify({type:'newQso'}));
}

// 页面加载时
window.onload = function() {
  refreshDevices();
  loadQso();
};
</script>
</body>
</html>


